Skip to content

Name the MultivariateNormal covariance cov in params - #438

Merged
alejandroschuler merged 2 commits into
masterfrom
fix/mvn-params-cov
Nov 10, 2026
Merged

alejandroschuler merged 2 commits into
masterfrom
fix/mvn-params-cov

Conversation

@alejandroschuler

Copy link
Copy Markdown
Collaborator

I found this while assessing #168 (see #433).

Problem

MultivariateNormal(k).params returns the covariance matrices under the key scale. Everywhere else in ngboost, scale is a standard deviation: Normal, Laplace, T and Cauchy all use it that way. So it is easy to read params["scale"] as a standard deviation. This happens in public code. jrharbin-york/SBTPredictors computes intervals with stats.norm.interval(interval_val, loc=locs[i][j], scale=scales[i][j][j]), where scales = y_dist_test.params["scale"]. That passes a variance where scipy expects a standard deviation, so the intervals are too wide where the variance is above 1 and too narrow where it is below 1.

The name also does not match scipy.stats.multivariate_normal, which calls the covariance cov. The distribution class itself already has a cov attribute.

Design

params now returns {"loc": ..., "cov": ...}. The values and their order do not change, only the second key.

To keep old code running for at least one release, params is a small dict subclass, MVNParams, defined in multivariate_normal.py. params["scale"] and params.get("scale") still return the covariance, but they give this FutureWarning, which points at the caller's line:

FutureWarning: params['scale'] of MultivariateNormal is deprecated and will be removed in a future release. It is the covariance matrix, not a standard deviation. Use params['cov'] instead.

scale is not stored as a key. It is handled in __missing__ and get. So keys(), items(), iteration and the repr show only loc and cov. The keys change once, now, and do not change again when the alias is removed.

loc stays. It is the key that every location family in ngboost uses, it matches the loc attribute of the class, and it is correct: it is the mean vector. Third-party code reads params["loc"] (see below).

Alternatives I rejected

  • Add cov and keep scale as a silent alias, with a note in the docstring. Nobody reads the docstring of a property they already use, so the code above would never learn that it reads a covariance. The repr would also print every covariance matrix twice.
  • Keep scale as a stored key that warns when read. The repr would still print the matrices twice and advertise the wrong name. Generic code that loops over the keys, such as the sample-weight test in tests/test_basic.py, would get a warning for a key it did not choose. The keys would also change a second time at removal.
  • Add mean and cov keys so that scipy.stats.multivariate_normal(**params) works. This cannot work with any key names. multivariate_normal takes one mean vector and one covariance matrix and does not broadcast over rows. With scipy 1.18.1, both the N-row arrays and a one-row slice (dist[0:1].params) raise ValueError. A mean key would only duplicate loc. Row by row, multivariate_normal(mean=params["loc"][i], cov=params["cov"][i]) works, and scipy_distribution() already returns these N objects. The new docstring says so.
  • Rename loc to mean. This would break every reader of params["loc"] for no gain in clarity.

Who reads these keys

In this repo, nothing reads params["scale"] of a MultivariateNormal:

  • ngboost/ does not read params at all.
  • examples/multivariate_normal.py reads y_dist.cov. notebooks/example_multivariate_normal.ipynb uses dists.loc and scipy_distribution(). docs/ only shows params for univariate distributions. None of these need a change.
  • tests/test_basic.py loops over params.items(), but only for Normal.

On GitHub (gh search code, which covers default branches only):

  • Hide-Hishi/CLIP-MMA reads params["loc"] and params["scale"] and passes them to multivariate_normal as the mean and covariance. This is correct. It keeps working and gets the warning.
  • jrharbin-york/SBTPredictors is the misuse above. It keeps running and gets a warning that says the value is a covariance.
  • MikeOMa/MV_Prediction, vanessamadu/seaducks, blei-lab/treeffuser and fortuinlab/prior-weighted-utilities read dist.cov or call scipy_distribution(). They are not affected.
  • skpro's ngboost adapters (skpro/regression/adapters/ngboost/_ngboost_proba.py) map Normal, Laplace, TDistribution, Poisson, LogNormal and Exponential. They do not support MultivariateNormal.

What can break

  • "scale" in params is now False, and params.keys() gives loc, cov. Code that uses the key name scale from iteration, or calls dict(params)["scale"] or params.pop("scale"), gets a KeyError. I found no such code.
  • Pickled models are not affected. A model stores the distribution name and k, and params is built at prediction time. The params dict itself also pickles, and the copy keeps the alias.

Example

rng = np.random.default_rng(0)
X = rng.normal(size=(200, 3))
Y = X[:, :2] + rng.normal(size=(200, 2)) * [1.0, 2.0]
ngb = NGBRegressor(Dist=MultivariateNormal(2), n_estimators=100, verbose=False).fit(X, Y)
params = ngb.pred_dist(X[:3]).params
list(params)        # master: ['loc', 'scale'], this PR: ['loc', 'cov']
params["cov"][0]    # [[1.162, 0.058], [0.058, 4.047]]
params["scale"][0]  # same matrix, with the FutureWarning above
multivariate_normal(mean=params["loc"][0], cov=params["cov"][0]).logpdf(Y[0])
# -3.3751, equal to ngb.pred_dist(X[:1]).logpdf(Y[:1])

Tests

  • test_multivariatenormal_params_cov (tests/test_distns.py) builds a distribution from a known covariance and checks that params["cov"] equals it, equals dist.cov, and gives the same log density through scipy.stats.multivariate_normal as dist.logpdf.
  • test_multivariatenormal_params_scale_is_deprecated checks that params["scale"] and params.get("scale") return the covariance with a FutureWarning that points at the caller, and that loc, cov, items() and unknown keys behave as in a plain dict, with no warning.
  • test_mvn_params_after_pickle (tests/test_pickling.py) checks that a pickled MultivariateNormal model gives the same loc and cov, and that the pickled params dict keeps the alias.
  • All three new tests fail on master.
  • Full suite: 137 passed, 59 skipped (134 passed, 59 skipped on master). With --slow, the 14 MultivariateNormal tests in tests/test_distns.py and tests/test_score.py pass.
  • black 24.10.0, isort 5.13.2, flake8 and pylint (options from .pre-commit-config.yaml) are clean on the changed files.

Overlap with #433

#433 changes __getattr__ in many distribution files but does not touch multivariate_normal.py, tests/test_distns.py or tests/test_pickling.py. Both PRs add a line to the 0.5.11 list in RELEASE_NOTES.md. #433 adds its lines at the top of the list and this PR adds its line at the bottom. git merge-tree of this branch and the head of #433 finds no conflict.

🤖 Generated with Claude Code

MultivariateNormal.params returned the covariance matrices under the key
scale. Everywhere else in ngboost scale is a standard deviation, so code
that reads params['scale'] can treat a covariance as one. One public
project passes the diagonal of params['scale'] to scipy.stats.norm as a
standard deviation. params now uses the key cov, the name that
scipy.stats.multivariate_normal and the cov attribute of the distribution
use. loc does not change.

params is now a small dict subclass. params['scale'] and
params.get('scale') still return the covariance, but they give a
FutureWarning that says it is a covariance and names cov. scale is not
stored as a key, so keys(), items() and the repr show only loc and cov,
and the keys will not change again when the alias goes.

Found while assessing #168.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ryan-wolbeck
ryan-wolbeck self-requested a review November 7, 2026 23:26
Resolve the RELEASE_NOTES.md conflict with the 0.6.0 rewrite in #461:
the cov note now sits in the 0.6.0 "New features" section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alejandroschuler
alejandroschuler merged commit c93c3a6 into master Nov 10, 2026
7 checks passed
alejandroschuler added a commit that referenced this pull request Nov 10, 2026
Master (#438) and this branch both added tests at the end of
tests/test_distns.py. The conflict keeps both blocks: the indexing tests
of this branch, then the MultivariateNormal params tests of #438.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alejandroschuler added a commit that referenced this pull request Nov 10, 2026
#438 documented params for a batch of N rows. dist[i] gives loc of shape p
and cov of shape p x p.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants